Skip to content

feat: Implement folder batch import and filmstrip UI#63

Open
amemya wants to merge 1 commit into
mainfrom
feat/batch-import
Open

feat: Implement folder batch import and filmstrip UI#63
amemya wants to merge 1 commit into
mainfrom
feat/batch-import

Conversation

@amemya

@amemya amemya commented Jun 13, 2026

Copy link
Copy Markdown
Owner
  • Unified 'Open Photos' and 'Open Folder' into a single OS dialog allowing both file and directory selection
  • Implemented filmstrip UI for batch image navigation
  • Extracted 'ensureValidExtension' helper in Go backend to safely validate and append extensions for batch exports
  • Fixed critical UI freeze bug caused by 'isSelectingRef' leaking on dialog cancellation
  • Corrected 'ExifResult' TypeScript interface to strictly match the flat JSON structure emitted by the Go backend
  • Implemented robust error skipping for 'filepath.Walk' to prevent a single unreadable file from aborting the entire import process
  • Cleaned up unused state ('openMenuVisible') and resolved variable shadowing in map callbacks
  • Removed deprecated click-to-open logic from the empty state in favor of clear action buttons

resolved #51

- Unified 'Open Photos' and 'Open Folder' into a single OS dialog allowing both file and directory selection
- Implemented filmstrip UI for batch image navigation
- Extracted 'ensureValidExtension' helper in Go backend to safely validate and append extensions for batch exports
- Fixed critical UI freeze bug caused by 'isSelectingRef' leaking on dialog cancellation
- Corrected 'ExifResult' TypeScript interface to strictly match the flat JSON structure emitted by the Go backend
- Implemented robust error skipping for 'filepath.Walk' to prevent a single unreadable file from aborting the entire import process
- Cleaned up unused state ('openMenuVisible') and resolved variable shadowing in map callbacks
- Removed deprecated click-to-open logic from the empty state in favor of clear action buttons
@amemya amemya self-assigned this Jun 13, 2026
@coderabbitai

coderabbitai Bot commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

新機能

  • 複数画像とフォルダの選択に対応しました
  • EXIFメタデータを複数画像に一括適用できるようになりました
  • 複数画像の同時エクスポート機能を追加
  • フィルムストリップでのプレビュー選択機能
  • ローディング表示とUIの改善

ウォークスルー

バックエンド API を拡張し、複数ファイル・フォルダの一括選択と EXIF 抽出に対応。フロントエンド状態を単一画像から複数画像配列に再構成し、個別選択・EXIF 編集・一括適用・バッチエクスポートのフロー を実装。UI とスタイルをマルチイメージ対応に再設計。

変更内容

マルチイメージとフォルダ対応

レイヤー / ファイル 説明
型定義 (ImportedImage & ExifResult)
frontend/src/types.ts
ImportedImage は取込画像(ファイルパス、Blob URL、MIME、EXIF、遅延ロード済みオブジェクト、エラー状態)を表現;ExifResult は EXIF 抽出戻り値(エラー・キャンセル・任意の URL・ファイルパス・MIME・各 EXIF 項目)を規定。
バックエンド - マルチパス処理と走査
app.go
OpenImages / OpenFolder / ProcessPaths を新規追加し、選択パスを再帰走査して JPG/PNG を収集、各ファイルに ProcessImageFile 適用。ensureValidExtension で拡張子統一検証(PNG は .png、JPEG 系は .jpg 許可)を共通化。
バックエンド - バッチ保存API
app.go
SaveImage は拡張子付与・検証を ensureValidExtension に委譲、無効時は SaveResult{Error: ...} 即返し。SaveBatchImage 新規追加で exportDir・exportName から保存パス組み立て、拡張子整合、保存トークン生成。
フロントエンド - マルチイメージ状態管理と初期化
frontend/src/App.tsx
importedImages[]/selectedIndex で複数画像管理、currentImage 派生値で選択中属性読取。selectedIndex 変更時に Image() で非同期ロード、失敗時 loadError 設定。handleExifResults で ExifResult[] を ImportedImage[] に変換。handleApplyToAll で選択中 EXIF を全画像複製。files-dropped は ProcessPaths に切替。
フロントエンド - キャンバス描画とバッチエクスポート
frontend/src/App.tsx
drawCanvas は isCanvasReady 制御追加、imageObj が null 時はスキップ(既存ピクセル保持)。downloadImage を canvas.toBlob Promise 化、blob 直送。downloadAllImages で各画像を offscreen canvas 描画 → SaveBatchImage → /api/save blob 直送 → 成功・失敗集計・トースト表示。
フロントエンド - UI再構成、スタイリング、全適用機能
frontend/src/App.tsx, frontend/src/App.css, frontend/src/components/MetadataSettingsPanel.tsx
App.tsx UI を Open / Export / Export All 中心に再構成、プレビューにローディングオーバーレイ と filmstrip(サムネで selectedIndex 切替)追加。App.css で btn-group・dropdown・loading-overlay・filmstrip スタイル拡張。preview-area に flex-direction: column、canvas-wrapper を flex: 1 に変更。MetadataSettingsPanel に onApplyToAll コールバック追加、複数画像時「Apply to All Images」ボタン表示。

推定レビュー工数

🎯 4 (Complex) | ⏱️ ~60 minutes

関連する可能性のある PR

  • amemya/ExifFrame#20: キャンバス描画フロー(drawCanvas / キャンバスサイズ配置)を変更し、フレームアスペクト比・向き・配置制御を追加。メイン PR のマルチイメージ・EXIF 駆動キャンバス再描画ロジックと直接重複する可能性あり。
  • amemya/ExifFrame#11: frontend/src/App.tsx の選択中状態管理・表示制御と App.css の UI 再構成(旧アップロード+設定中心から新トップバー・ワークスペース・サイドバー中心へ)が、メイン PR のマルチイメージ対応に伴う UI・状態更新と同じ箇所を大きく変更。
  • amemya/ExifFrame#15: app.go で SaveImage の拡張子検証ロジックを ensureValidExtension に整理し、SaveBatchImage を新設して保存トークン生成・保存フロー(SaveResult / 保存 API)に接続。retrieved PR の handler / /api/save token ベース保存方式変更(SaveImage 引数・SaveResult / ExifResult)と直接コードレベルで整合。
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately reflects the main changes: implementing folder batch import functionality and a filmstrip UI for image navigation.
Description check ✅ Passed The description is directly related to the changeset, detailing multiple improvements including folder batch import, filmstrip UI, backend validation, UI fixes, and resolved issue #51.
Linked Issues check ✅ Passed The PR successfully addresses issue #51 by implementing folder-level batch processing with OpenFolder and ProcessPaths functions, enabling users to select and process multiple images from a directory.
Out of Scope Changes check ✅ Passed All changes are directly related to batch import and filmstrip UI implementation as required by issue #51, including backend functions, frontend state management, UI components, and type definitions.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/batch-import

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app.go`:
- Around line 155-203: ProcessPaths collects duplicate file paths into
validPaths causing duplicate processing; fix by normalizing each candidate path
(use filepath.Clean and preferably filepath.Abs) and deduplicating before
calling ProcessImageFile: maintain a map[string]struct{} seen and when adding to
validPaths check seen[normalized]==false then mark seen[normalized]=struct{}{}
and append; you can apply this either when you append inside the Walk callback
and the else branch (replace direct validPaths.append with the normalized+seen
check) or run a short dedupe pass over validPaths before the final loop; keep
using a.ProcessImageFile(path) afterward with the normalized path.
- Around line 490-507: SaveBatchImage currently builds savePath with
filepath.Join(exportDir, exportName) without validating the target is inside
exportDir; fix SaveBatchImage to (1) reject empty exportDir, (2) reject
exportName if filepath.IsAbs(exportName) or if filepath.Clean(exportName)
contains ".." path elements, (3) construct candidate := filepath.Join(exportDir,
exportName), resolve abs paths with filepath.Abs and filepath.Clean for both
exportDir and candidate, then use filepath.Rel(exportDirAbs, candidateAbs) and
ensure the relative path does not start with ".." (or return a SaveResult
error), and only then call ensureValidExtension(candidate, isPng) and proceed to
prepareSave; update references to savePath in the function accordingly so no
unvalidated path reaches handler.prepareSave.

In `@frontend/src/App.tsx`:
- Around line 703-709: The code always calls showToast("Export complete!") even
when the HTTP POST failed; update the save/export flow (the block that checks
response.ok in the try/catch where response and exportName are used) to only
show the success toast on actual success—either add a return after handling the
error inside the if (!response.ok) branch or move the showToast call into the
success path after the response.ok check so that failed POSTs do not display
"Export complete!".
- Around line 947-953: The filmstrip item uses a non-focusable <div> with
onClick (className "filmstrip-item", selectedIndex and setSelectedIndex) which
prevents keyboard selection; change the element to a semantic <button> (or if
you must keep a div: add role="button", tabIndex={0} and keydown handler for
Enter/Space that calls setSelectedIndex(idx)) so it can receive focus and
activate via keyboard, preserve the existing id (`thumb-${idx}`), img src
(img.imageURL), alt text, loading and draggable props, and ensure the selected
styling still toggles based on selectedIndex.
- Around line 390-410: The img.onload / img.onerror handlers currently spread
the stale captured variable current into the updated item, which can overwrite
newer EXIF edits made via setExif; change the setImportedImages updater in both
img.onload and img.onerror to base the replacement on the existing prev entry
(use prev[idx] or prev[selectedIndex] as the source) and only overwrite the
specific fields (imageObj or loadError) so concurrent edits to other fields are
preserved; keep the same index-finding logic and only mutate those fields when
idx !== -1.
- Around line 770-809: The current batch export loop lets exceptions from
AppAPI.SaveBatchImage, offCanvas.toBlob, or the fetch call bubble up and abort
the entire operation; fix this by wrapping the per-image processing logic (the
code that calls AppAPI.SaveBatchImage, creates the Blob via offCanvas.toBlob,
and posts it with fetch) in its own try/catch so any error increments failCount
and continues to the next image, and ensure caught errors are logged
(console.error) and do not rethrow so the outer try only handles truly fatal
errors; adjust successCount/failCount updates accordingly and keep the existing
overall success/failure showToast behavior.
- Around line 430-451: When replacing importedImages you must revoke previous
blob URLs to avoid memory leaks: in the setImportedImages updater (the callback
passed to setImportedImages) iterate over the previous state and call
URL.revokeObjectURL(img.imageURL) for each imageURL that is a blob URL before
returning the new ImportedImage[] (check for img.imageURL &&
img.imageURL.startsWith('blob:') to be safe); additionally add a useEffect
cleanup that runs on unmount to revoke any remaining importedImages' imageURL
values (again checking for blob:), and keep using URL.revokeObjectURL when you
remove/replace individual ImportedImage entries elsewhere; reference the
setImportedImages updater, ImportedImage.imageURL, and add a component-level
useEffect cleanup to complete the fix.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c93ecb16-0067-4fa9-b219-998592089ad1

📥 Commits

Reviewing files that changed from the base of the PR and between 160cbcf and 30aa737.

📒 Files selected for processing (5)
  • app.go
  • frontend/src/App.css
  • frontend/src/App.tsx
  • frontend/src/components/MetadataSettingsPanel.tsx
  • frontend/src/types.ts

Comment thread app.go
Comment on lines +155 to +203
// ProcessPaths recursively walks provided paths (or single files) and processes valid images.
func (a *App) ProcessPaths(paths []string) []ExifResult {
var results []ExifResult
var validPaths []string

for _, p := range paths {
info, err := os.Stat(p)
if err != nil {
results = append(results, ExifResult{Error: "Failed to access path: " + err.Error(), FilePath: p})
continue
}

if info.IsDir() {
err = filepath.Walk(p, func(path string, info os.FileInfo, err error) error {
if err != nil {
log.Printf("Error accessing path %s: %v", path, err)
return nil // Skip this file/folder but continue walking
}
if !info.IsDir() {
lower := strings.ToLower(path)
if strings.HasSuffix(lower, ".jpg") || strings.HasSuffix(lower, ".jpeg") || strings.HasSuffix(lower, ".png") {
validPaths = append(validPaths, path)
}
}
return nil
})
if err != nil {
results = append(results, ExifResult{Error: "Failed to read directory: " + err.Error(), FilePath: p})
}
} else {
validPaths = append(validPaths, p)
}
}

for _, path := range validPaths {
res := a.ProcessImageFile(path)
if res.Error != "" {
log.Printf("Skipped file %s: %v", path, res.Error)
continue
}
results = append(results, res)
}

if len(results) == 0 {
return []ExifResult{{Error: "No valid images found in the selected paths."}}
}

return results
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

重複選択時に同一画像が二重取り込みされます。

Line 160-186 では validPaths の重複排除がないため、フォルダとその配下ファイルを同時選択したケースで同じパスが複数回 ProcessImageFile されます。filmstrip と一括エクスポート結果が重複します。

🔧 修正案(重複パス除去)
 func (a *App) ProcessPaths(paths []string) []ExifResult {
 	var results []ExifResult
 	var validPaths []string
+	seen := make(map[string]struct{})
+	addValidPath := func(p string) {
+		clean := filepath.Clean(p)
+		if _, ok := seen[clean]; ok {
+			return
+		}
+		seen[clean] = struct{}{}
+		validPaths = append(validPaths, clean)
+	}
 
 	for _, p := range paths {
 		info, err := os.Stat(p)
@@
 				if !info.IsDir() {
 					lower := strings.ToLower(path)
 					if strings.HasSuffix(lower, ".jpg") || strings.HasSuffix(lower, ".jpeg") || strings.HasSuffix(lower, ".png") {
-						validPaths = append(validPaths, path)
+						addValidPath(path)
 					}
 				}
 				return nil
@@
 		} else {
-			validPaths = append(validPaths, p)
+			addValidPath(p)
 		}
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// ProcessPaths recursively walks provided paths (or single files) and processes valid images.
func (a *App) ProcessPaths(paths []string) []ExifResult {
var results []ExifResult
var validPaths []string
for _, p := range paths {
info, err := os.Stat(p)
if err != nil {
results = append(results, ExifResult{Error: "Failed to access path: " + err.Error(), FilePath: p})
continue
}
if info.IsDir() {
err = filepath.Walk(p, func(path string, info os.FileInfo, err error) error {
if err != nil {
log.Printf("Error accessing path %s: %v", path, err)
return nil // Skip this file/folder but continue walking
}
if !info.IsDir() {
lower := strings.ToLower(path)
if strings.HasSuffix(lower, ".jpg") || strings.HasSuffix(lower, ".jpeg") || strings.HasSuffix(lower, ".png") {
validPaths = append(validPaths, path)
}
}
return nil
})
if err != nil {
results = append(results, ExifResult{Error: "Failed to read directory: " + err.Error(), FilePath: p})
}
} else {
validPaths = append(validPaths, p)
}
}
for _, path := range validPaths {
res := a.ProcessImageFile(path)
if res.Error != "" {
log.Printf("Skipped file %s: %v", path, res.Error)
continue
}
results = append(results, res)
}
if len(results) == 0 {
return []ExifResult{{Error: "No valid images found in the selected paths."}}
}
return results
}
// ProcessPaths recursively walks provided paths (or single files) and processes valid images.
func (a *App) ProcessPaths(paths []string) []ExifResult {
var results []ExifResult
var validPaths []string
seen := make(map[string]struct{})
addValidPath := func(p string) {
clean := filepath.Clean(p)
if _, ok := seen[clean]; ok {
return
}
seen[clean] = struct{}{}
validPaths = append(validPaths, clean)
}
for _, p := range paths {
info, err := os.Stat(p)
if err != nil {
results = append(results, ExifResult{Error: "Failed to access path: " + err.Error(), FilePath: p})
continue
}
if info.IsDir() {
err = filepath.Walk(p, func(path string, info os.FileInfo, err error) error {
if err != nil {
log.Printf("Error accessing path %s: %v", path, err)
return nil // Skip this file/folder but continue walking
}
if !info.IsDir() {
lower := strings.ToLower(path)
if strings.HasSuffix(lower, ".jpg") || strings.HasSuffix(lower, ".jpeg") || strings.HasSuffix(lower, ".png") {
addValidPath(path)
}
}
return nil
})
if err != nil {
results = append(results, ExifResult{Error: "Failed to read directory: " + err.Error(), FilePath: p})
}
} else {
addValidPath(p)
}
}
for _, path := range validPaths {
res := a.ProcessImageFile(path)
if res.Error != "" {
log.Printf("Skipped file %s: %v", path, res.Error)
continue
}
results = append(results, res)
}
if len(results) == 0 {
return []ExifResult{{Error: "No valid images found in the selected paths."}}
}
return results
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app.go` around lines 155 - 203, ProcessPaths collects duplicate file paths
into validPaths causing duplicate processing; fix by normalizing each candidate
path (use filepath.Clean and preferably filepath.Abs) and deduplicating before
calling ProcessImageFile: maintain a map[string]struct{} seen and when adding to
validPaths check seen[normalized]==false then mark seen[normalized]=struct{}{}
and append; you can apply this either when you append inside the Walk callback
and the else branch (replace direct validPaths.append with the normalized+seen
check) or run a short dedupe pass over validPaths before the final loop; keep
using a.ProcessImageFile(path) afterward with the normalized path.

Comment thread app.go
Comment on lines +490 to +507
// SaveBatchImage bypasses ExportFolder validation for explicit batch exports.
func (a *App) SaveBatchImage(isPng bool, exportDir string, exportName string) SaveResult {
savePath := filepath.Join(exportDir, exportName)
savePath, err := ensureValidExtension(savePath, isPng)
if err != nil {
return SaveResult{Error: err.Error()}
}

expectedMime := "image/jpeg"
if isPng {
expectedMime = "image/png"
}
if a.handler == nil {
return SaveResult{Error: "Internal error: image handler not initialized"}
}
token := a.handler.prepareSave(savePath, expectedMime)
return SaveResult{SaveToken: token}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

SaveBatchImage が保存先ディレクトリ境界を検証していません。

Line 492-493 は filepath.Join(exportDir, exportName) を未検証で使っており、exportName../ や絶対パスが入ると exportDir 外へ保存可能です。さらに exportDir が空の場合、相対パス保存になります。バッチ保存API側で境界検証を入れてください。

🛡️ 修正案(空値チェック + ディレクトリ外書き込み防止)
 func (a *App) SaveBatchImage(isPng bool, exportDir string, exportName string) SaveResult {
-	savePath := filepath.Join(exportDir, exportName)
-	savePath, err := ensureValidExtension(savePath, isPng)
+	if strings.TrimSpace(exportDir) == "" {
+		return SaveResult{Error: "Export directory is required"}
+	}
+	if strings.TrimSpace(exportName) == "" {
+		return SaveResult{Error: "Export filename is required"}
+	}
+
+	baseDir := filepath.Clean(exportDir)
+	candidate := filepath.Join(baseDir, exportName)
+	rel, err := filepath.Rel(baseDir, candidate)
+	if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
+		return SaveResult{Error: "Invalid export filename"}
+	}
+
+	savePath, err := ensureValidExtension(candidate, isPng)
 	if err != nil {
 		return SaveResult{Error: err.Error()}
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// SaveBatchImage bypasses ExportFolder validation for explicit batch exports.
func (a *App) SaveBatchImage(isPng bool, exportDir string, exportName string) SaveResult {
savePath := filepath.Join(exportDir, exportName)
savePath, err := ensureValidExtension(savePath, isPng)
if err != nil {
return SaveResult{Error: err.Error()}
}
expectedMime := "image/jpeg"
if isPng {
expectedMime = "image/png"
}
if a.handler == nil {
return SaveResult{Error: "Internal error: image handler not initialized"}
}
token := a.handler.prepareSave(savePath, expectedMime)
return SaveResult{SaveToken: token}
}
// SaveBatchImage bypasses ExportFolder validation for explicit batch exports.
func (a *App) SaveBatchImage(isPng bool, exportDir string, exportName string) SaveResult {
if strings.TrimSpace(exportDir) == "" {
return SaveResult{Error: "Export directory is required"}
}
if strings.TrimSpace(exportName) == "" {
return SaveResult{Error: "Export filename is required"}
}
baseDir := filepath.Clean(exportDir)
candidate := filepath.Join(baseDir, exportName)
rel, err := filepath.Rel(baseDir, candidate)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return SaveResult{Error: "Invalid export filename"}
}
savePath, err := ensureValidExtension(candidate, isPng)
if err != nil {
return SaveResult{Error: err.Error()}
}
expectedMime := "image/jpeg"
if isPng {
expectedMime = "image/png"
}
if a.handler == nil {
return SaveResult{Error: "Internal error: image handler not initialized"}
}
token := a.handler.prepareSave(savePath, expectedMime)
return SaveResult{SaveToken: token}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app.go` around lines 490 - 507, SaveBatchImage currently builds savePath with
filepath.Join(exportDir, exportName) without validating the target is inside
exportDir; fix SaveBatchImage to (1) reject empty exportDir, (2) reject
exportName if filepath.IsAbs(exportName) or if filepath.Clean(exportName)
contains ".." path elements, (3) construct candidate := filepath.Join(exportDir,
exportName), resolve abs paths with filepath.Abs and filepath.Clean for both
exportDir and candidate, then use filepath.Rel(exportDirAbs, candidateAbs) and
ensure the relative path does not start with ".." (or return a SaveResult
error), and only then call ensureValidExtension(candidate, isPng) and proceed to
prepareSave; update references to savePath in the function accordingly so no
unvalidated path reaches handler.prepareSave.

Comment thread frontend/src/App.tsx
Comment on lines +390 to +410
setImportedImages(prev => {
const newImages = [...prev];
// Make sure the image we loaded is still at this index (or find by url if order changed, but we only append for now)
if (newImages[selectedIndex] && newImages[selectedIndex].imageURL === current.imageURL) {
newImages[selectedIndex] = { ...current, imageObj: img };
} else {
// Fallback: find it
const idx = newImages.findIndex(item => item.imageURL === current.imageURL);
if (idx !== -1) newImages[idx] = { ...current, imageObj: img };
}
return newImages;
});
setOrientation(img.height > img.width ? "portrait" : "landscape");
setImageLoaded(true);
resolve();
};
img.onerror = () => {
console.error("Failed to load image");
showToast("Failed to load image preview");
reject(new Error("Failed to load image"));
setImportedImages(prev => {
const newImages = [...prev];
const idx = newImages.findIndex(item => item.imageURL === current.imageURL);
if (idx !== -1) newImages[idx] = { ...current, loadError: true };
return newImages;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

非同期ロード完了時に古い current を再代入して、直近のEXIF編集を巻き戻す可能性があります。

img.onload / img.onerror{ ...current, ... } を使っているため、ロード中に行われた setExif 更新が失われます。prev[idx] をベースに imageObj / loadError だけ更新してください。

💡 修正案
img.onload = () => {
  setImportedImages(prev => {
    const newImages = [...prev];
-   if (newImages[selectedIndex] && newImages[selectedIndex].imageURL === current.imageURL) {
-       newImages[selectedIndex] = { ...current, imageObj: img };
-   } else {
-       const idx = newImages.findIndex(item => item.imageURL === current.imageURL);
-       if (idx !== -1) newImages[idx] = { ...current, imageObj: img };
-   }
+   const idx = newImages.findIndex(item => item.imageURL === current.imageURL);
+   if (idx !== -1) {
+       newImages[idx] = { ...newImages[idx], imageObj: img, loadError: false };
+   }
    return newImages;
  });
};

img.onerror = () => {
  showToast("Failed to load image preview");
  setImportedImages(prev => {
    const newImages = [...prev];
    const idx = newImages.findIndex(item => item.imageURL === current.imageURL);
-   if (idx !== -1) newImages[idx] = { ...current, loadError: true };
+   if (idx !== -1) newImages[idx] = { ...newImages[idx], loadError: true };
    return newImages;
  });
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/App.tsx` around lines 390 - 410, The img.onload / img.onerror
handlers currently spread the stale captured variable current into the updated
item, which can overwrite newer EXIF edits made via setExif; change the
setImportedImages updater in both img.onload and img.onerror to base the
replacement on the existing prev entry (use prev[idx] or prev[selectedIndex] as
the source) and only overwrite the specific fields (imageObj or loadError) so
concurrent edits to other fields are preserved; keep the same index-finding
logic and only mutate those fields when idx !== -1.

Comment thread frontend/src/App.tsx
Comment on lines +430 to 451
setImportedImages(() => {
const newImages: ImportedImage[] = validResults.map(r => ({
filePath: r.filePath || "",
imageURL: r.imageURL!,
sourceMimeType: (r.mimeType as 'image/jpeg' | 'image/png') || 'image/jpeg',
imageObj: null,
exif: {
camera: r.camera || "",
lens: r.lens || "",
focalLength: r.focalLength || "",
aperture: r.aperture || "",
shutterSpeed: r.shutterSpeed || "",
iso: r.iso || "",
film: "",
developer: "",
dilution: "",
temperature: "",
time: "",
}
}));
return newImages;
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

importedImages を置き換えるたびに旧 imageURL の解放がなく、バッチ利用でメモリが増加し続けます。

ImportedImage.imageURL は Blob URL 前提なので、差し替え時とアンマウント時に URL.revokeObjectURL で解放してください。

💡 修正案
- setImportedImages(() => {
+ setImportedImages(prev => {
+     for (const oldImg of prev) {
+         if (oldImg.imageURL.startsWith('blob:')) {
+             URL.revokeObjectURL(oldImg.imageURL);
+         }
+     }
      const newImages: ImportedImage[] = validResults.map(r => ({
          filePath: r.filePath || "",
          imageURL: r.imageURL!,
          sourceMimeType: (r.mimeType as 'image/jpeg' | 'image/png') || 'image/jpeg',
          imageObj: null,
          exif: {
              camera: r.camera || "",
              lens: r.lens || "",
              focalLength: r.focalLength || "",
              aperture: r.aperture || "",
              shutterSpeed: r.shutterSpeed || "",
              iso: r.iso || "",
              film: "",
              developer: "",
              dilution: "",
              temperature: "",
              time: "",
          }
      }));
      return newImages;
  });
+ useEffect(() => {
+   return () => {
+     for (const img of importedImages) {
+       if (img.imageURL.startsWith('blob:')) {
+         URL.revokeObjectURL(img.imageURL);
+       }
+     }
+   };
+ }, [importedImages]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
setImportedImages(() => {
const newImages: ImportedImage[] = validResults.map(r => ({
filePath: r.filePath || "",
imageURL: r.imageURL!,
sourceMimeType: (r.mimeType as 'image/jpeg' | 'image/png') || 'image/jpeg',
imageObj: null,
exif: {
camera: r.camera || "",
lens: r.lens || "",
focalLength: r.focalLength || "",
aperture: r.aperture || "",
shutterSpeed: r.shutterSpeed || "",
iso: r.iso || "",
film: "",
developer: "",
dilution: "",
temperature: "",
time: "",
}
}));
return newImages;
});
setImportedImages(prev => {
for (const oldImg of prev) {
if (oldImg.imageURL.startsWith('blob:')) {
URL.revokeObjectURL(oldImg.imageURL);
}
}
const newImages: ImportedImage[] = validResults.map(r => ({
filePath: r.filePath || "",
imageURL: r.imageURL!,
sourceMimeType: (r.mimeType as 'image/jpeg' | 'image/png') || 'image/jpeg',
imageObj: null,
exif: {
camera: r.camera || "",
lens: r.lens || "",
focalLength: r.focalLength || "",
aperture: r.aperture || "",
shutterSpeed: r.shutterSpeed || "",
iso: r.iso || "",
film: "",
developer: "",
dilution: "",
temperature: "",
time: "",
}
}));
return newImages;
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/App.tsx` around lines 430 - 451, When replacing importedImages
you must revoke previous blob URLs to avoid memory leaks: in the
setImportedImages updater (the callback passed to setImportedImages) iterate
over the previous state and call URL.revokeObjectURL(img.imageURL) for each
imageURL that is a blob URL before returning the new ImportedImage[] (check for
img.imageURL && img.imageURL.startsWith('blob:') to be safe); additionally add a
useEffect cleanup that runs on unmount to revoke any remaining importedImages'
imageURL values (again checking for blob:), and keep using URL.revokeObjectURL
when you remove/replace individual ImportedImage entries elsewhere; reference
the setImportedImages updater, ImportedImage.imageURL, and add a component-level
useEffect cleanup to complete the fix.

Comment thread frontend/src/App.tsx
Comment on lines 703 to 709
if (!response.ok) {
const errText = await response.text();
console.error("Save failed:", errText);
alert("Failed to save image: " + errText);
} else {
showToast("Image saved successfully");
const text = await response.text();
console.error("HTTP POST failed for", exportName, text);
alert("Failed to save image via HTTP POST: " + text);
}
showToast("Export complete!");
} catch (err) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

保存失敗時でも「Export complete!」トーストが必ず表示されています。

HTTP POST 失敗分岐で return するか、成功時のみトースト表示にしてください。

💡 修正案
if (!response.ok) {
    const text = await response.text();
    console.error("HTTP POST failed for", exportName, text);
    alert("Failed to save image via HTTP POST: " + text);
+   return;
}
showToast("Export complete!");
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (!response.ok) {
const errText = await response.text();
console.error("Save failed:", errText);
alert("Failed to save image: " + errText);
} else {
showToast("Image saved successfully");
const text = await response.text();
console.error("HTTP POST failed for", exportName, text);
alert("Failed to save image via HTTP POST: " + text);
}
showToast("Export complete!");
} catch (err) {
if (!response.ok) {
const text = await response.text();
console.error("HTTP POST failed for", exportName, text);
alert("Failed to save image via HTTP POST: " + text);
return;
}
showToast("Export complete!");
} catch (err) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/App.tsx` around lines 703 - 709, The code always calls
showToast("Export complete!") even when the HTTP POST failed; update the
save/export flow (the block that checks response.ok in the try/catch where
response and exportName are used) to only show the success toast on actual
success—either add a return after handling the error inside the if
(!response.ok) branch or move the showToast call into the success path after the
response.ok check so that failed POSTs do not display "Export complete!".

Comment thread frontend/src/App.tsx
Comment on lines +770 to +809
const result = await AppAPI.SaveBatchImage(isPng, exportDir, exportName);
if (result.error) {
console.error("Export failed for", exportName, result.error);
failCount++;
continue;
}

const blob = await new Promise<Blob>((resolve, reject) => {
offCanvas.toBlob(
(b) => b ? resolve(b) : reject(new Error("toBlob returned null")),
targetMime,
1.0
);
});

const response = await fetch(`/api/save?token=${result.saveToken}`, {
method: 'POST',
body: blob,
headers: { 'Content-Type': targetMime }
});

if (!response.ok) {
const text = await response.text();
console.error("Save failed:", text);
failCount++;
continue;
}

successCount++;
}

if (failCount > 0) {
showToast(`Export complete: ${successCount} succeeded, ${failCount} failed.`);
} else {
showToast(`Successfully exported all ${successCount} images.`);
}
} catch (err: any) {
console.error("Failed to export all:", err);
showToast("Failed to export all: " + err.message);
} finally {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

バッチエクスポートが1件の例外で全体中断します。

ループ内の SaveBatchImage / toBlob / fetch の例外が外側 catch に伝播すると、残り画像の処理が止まります。各画像ごとに try/catch して failCount++ で継続してください。

💡 修正案
for (let i = 0; i < importedImages.length; i++) {
-   const imgState = importedImages[i];
-   let imgToDraw = imgState.imageObj;
-   ...
-   const result = await AppAPI.SaveBatchImage(isPng, exportDir, exportName);
-   ...
-   const blob = await new Promise<Blob>((resolve, reject) => { ... });
-   const response = await fetch(`/api/save?token=${result.saveToken}`, { ... });
-   if (!response.ok) { ... }
-   successCount++;
+   try {
+     const imgState = importedImages[i];
+     let imgToDraw = imgState.imageObj;
+     ...
+     const result = await AppAPI.SaveBatchImage(isPng, exportDir, exportName);
+     ...
+     const blob = await new Promise<Blob>((resolve, reject) => { ... });
+     const response = await fetch(`/api/save?token=${result.saveToken}`, { ... });
+     if (!response.ok) {
+       const text = await response.text();
+       console.error("Save failed:", text);
+       failCount++;
+       continue;
+     }
+     successCount++;
+   } catch (e) {
+     console.error("Failed to export image:", e);
+     failCount++;
+     continue;
+   }
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/App.tsx` around lines 770 - 809, The current batch export loop
lets exceptions from AppAPI.SaveBatchImage, offCanvas.toBlob, or the fetch call
bubble up and abort the entire operation; fix this by wrapping the per-image
processing logic (the code that calls AppAPI.SaveBatchImage, creates the Blob
via offCanvas.toBlob, and posts it with fetch) in its own try/catch so any error
increments failCount and continues to the next image, and ensure caught errors
are logged (console.error) and do not rethrow so the outer try only handles
truly fatal errors; adjust successCount/failCount updates accordingly and keep
the existing overall success/failure showToast behavior.

Comment thread frontend/src/App.tsx
Comment on lines +947 to +953
<div
key={idx}
className={`filmstrip-item ${selectedIndex === idx ? 'selected' : ''}`}
onClick={() => setSelectedIndex(idx)}
>
<img id={`thumb-${idx}`} src={img.imageURL} alt={`Thumbnail ${idx}`} loading="lazy" draggable={false} />
</div>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

フィルムストリップ項目がキーボード操作不可で、画像選択のアクセシビリティ要件を満たしていません。

div のクリック実装だとフォーカス移動・Enter/Space 操作ができません。button 要素化(または role="button" + tabIndex + キー処理)を入れてください。

💡 修正案
- <div 
+ <button
+   type="button"
    key={idx} 
    className={`filmstrip-item ${selectedIndex === idx ? 'selected' : ''}`}
    onClick={() => setSelectedIndex(idx)}
+   aria-label={`画像 ${idx + 1} を選択`}
+   aria-pressed={selectedIndex === idx}
  >
    <img id={`thumb-${idx}`} src={img.imageURL} alt={`Thumbnail ${idx}`} loading="lazy" draggable={false} />
- </div>
+ </button>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/App.tsx` around lines 947 - 953, The filmstrip item uses a
non-focusable <div> with onClick (className "filmstrip-item", selectedIndex and
setSelectedIndex) which prevents keyboard selection; change the element to a
semantic <button> (or if you must keep a div: add role="button", tabIndex={0}
and keydown handler for Enter/Space that calls setSelectedIndex(idx)) so it can
receive focus and activate via keyboard, preserve the existing id
(`thumb-${idx}`), img src (img.imageURL), alt text, loading and draggable props,
and ensure the selected styling still toggles based on selectedIndex.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

フォルダー単位の一括フレーム化

1 participant